/logout 後,同一個 token 打 /me 仍然回 200這兩個問題互相拉扯:token 效期越短越安全,但使用者越常被踢出去;想讓登出生效,伺服器就得記住「哪些 token 還算數」,而這又違背了 JWT 不用查表的初衷。
今天用兩個機制同時解決:
第 2 週:檢測優化與生產系統
今天要完成:
src/auth.py:access token 15 分鐘、refresh token 7 天,彼此不能混用src/session_manager.py:登記、驗證、輪換、撤銷、閒置超時src/api_auth.py:登入回傳 refresh token;新增 /refresh、/logout-all、/sessions;/logout 真正撤銷frontend/apiClient.js:遇到 401 自動換 token 並重試,使用者無感tests/test_day17_refresh_token.py:10 個測試,其中 3 個走真實的 APIaccess token 15 分鐘 每個 API 請求都帶著它 外洩時損害小(很快過期)
refresh token 7 天 只在 access token 過期時使用 只送到 /refresh,暴露機會少
access token 過期時,前端拿 refresh token 呼叫 /refresh 換一組新的,使用者完全感覺不到。只有 refresh token 也失效(7 天沒用、被登出、閒置超時)時,才需要重新登入。
如果 refresh token 可以重複使用,它一旦外洩,攻擊者就有 7 天可以不斷換新的 access token。輪換的做法是:每次 /refresh 都發一組新的 refresh token,舊的立刻作廢。攻擊者拿到的舊 refresh token 只要被合法使用者先用過,就換不到任何東西。
「作廢」這件事,純 JWT 做不到:簽章有效、還沒到期的 token,伺服器沒有理由拒絕。所以要在伺服器記一張表:
user_id → [ Session(access_token, refresh_token, created_at, last_activity, ip, user_agent), ... ]
每個請求除了驗簽章,還要確認 token 還在這張表裡。登出就是把它從表裡刪掉。
這一天的程式碼原本就存在(create_refresh_token、SessionManager 都寫好了,也有 7 個單元測試),但實測後發現它完全沒有接到 API,而且本身有幾個 bug:
| 問題 | 影響 |
|---|---|
登入不回傳 refresh token,也沒有 /refresh 端點 |
refresh token 根本用不到 |
get_current_user 不檢查會話 |
登出、閒置超時都不會讓 token 失效 |
verify_token() 不檢查令牌類型 |
7 天效期的 refresh token 可以直接當 access token 呼叫任何 API |
refresh_access_token() 發新 refresh token,但舊的仍有效 |
輪換沒有作用 |
get_session() 先更新活動時間、再判斷是否閒置 |
永遠不會判定為閒置 |
cleanup_expired_sessions() 以 access token 的 15 分鐘判斷過期 |
refresh token 還有 7 天,會話卻在 15 分鐘後被清掉;回傳值也不是清掉的數量 |
JWT 只有 sub 與 exp |
同一秒內登入兩次,拿到一模一樣的 token,會話無法區分 |
第三點是最嚴重的:縮短 access token 效期的所有好處,都被「refresh token 也能當 access token 用」抵銷了。下面逐一修正。
POST /login → 簽發 access + refresh → session_manager.create_session()
任何受保護請求 → verify_token(拒絕 refresh token)→ session_manager.get_session()
└─ 不存在或閒置超時 → 401「會話已失效,請重新登入」
POST /refresh → verify_refresh_token → get_session_by_refresh → rotate_session(舊的作廢)
POST /logout → revoke_session(這台裝置)
POST /logout-all → revoke_all_sessions(所有裝置)
GET /sessions → 列出登入中的裝置
src/auth.py、src/session_manager.py、src/api_auth.py、frontend/apiClient.js、frontend/App.jsx(修改);tests/test_day17_refresh_token.py(修改)專案結構變化:
srs-review-agent/
├── src/
│ ├── auth.py ← 修改:令牌 type 與 jti、verify_token 拒絕 refresh token
│ ├── session_manager.py ← 修改:輪換、閒置檢查、清理邏輯
│ └── api_auth.py ← 修改:登入建立會話、/refresh、/logout-all、/sessions
├── frontend/
│ ├── apiClient.js ← 修改:tokenStore、authRequest 自動刷新
│ └── App.jsx ← 修改:保存 refresh token、會話失效時回到登入畫面
└── tests/
└── test_day17_refresh_token.py ← 修改:10 個測試
今天不需要新的套件。可調整的環境變數:
| 變數 | 預設 | 說明 |
|---|---|---|
ACCESS_TOKEN_EXPIRE_MINUTES |
15 |
access token 效期(分鐘);測試過期情境時可設為 1 |
SESSION_INACTIVITY_MINUTES |
30 |
閒置超過這個時間自動登出 |
修改 src/auth.py:
import uuid
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
REFRESH_TOKEN_EXPIRE_DAYS = 7
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
# type:與刷新令牌區分;jti:每個令牌唯一(同一秒登入兩次也不會得到相同令牌)
to_encode.update({"exp": expire, "type": "access", "jti": uuid.uuid4().hex})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh", "jti": uuid.uuid4().hex})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
verify_token() 加上一行檢查:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
# 刷新令牌效期 7 天,不能拿來當訪問令牌呼叫 API
if payload.get("type") == "refresh":
return None
jti(JWT ID)的必要性容易被忽略:JWT 的內容只有 sub 和 exp,而 exp 精確到秒。同一個用戶在同一秒內登入兩次(例如同時開兩個分頁),拿到的 token 會完全相同,會話表就分不出這是兩台裝置。
修改 src/session_manager.py。會話本身是一個 dataclass:
@dataclass
class Session:
user_id: str
access_token: str
refresh_token: str
created_at: datetime = field(default_factory=datetime.utcnow) # 目前這組令牌的簽發時間
last_activity: datetime = field(default_factory=datetime.utcnow)
ip_address: str = ""
user_agent: str = ""
def is_inactive(self, timeout_minutes: int = 30) -> bool:
elapsed = (datetime.utcnow() - self.last_activity).total_seconds() / 60
return elapsed > timeout_minutes
def rotate(self, access_token: str, refresh_token: str):
"""換上新的一組令牌(刷新令牌輪換)"""
self.access_token = access_token
self.refresh_token = refresh_token
self.created_at = datetime.utcnow()
self.update_activity()
SessionManager 用 user_id → [Session, ...] 保存會話,所有操作都在 threading.Lock 內進行(FastAPI 會在多個執行緒處理請求)。驗證 access token 時,先判斷閒置、再更新活動時間:
def get_session(self, user_id: str, access_token: str) -> Optional[Session]:
with self.lock:
for session in self.sessions.get(user_id, []):
if session.access_token == access_token:
if session.is_inactive(self.inactivity_minutes):
self.sessions[user_id].remove(session) # 閒置過久:自動登出
return None
session.update_activity()
return session
return None
原本的順序是反過來的:先 update_activity() 再檢查,於是 last_activity 永遠是「剛剛」,閒置超時永遠不會觸發。
輪換必須是原子操作:
def rotate_session(self, user_id: str, old_refresh_token: str,
new_access_token: str, new_refresh_token: str) -> bool:
with self.lock:
for session in self.sessions.get(user_id, []):
if session.refresh_token == old_refresh_token:
session.rotate(new_access_token, new_refresh_token)
return True
return False
「找到舊 refresh token」與「換成新的」在同一把鎖內完成。兩個請求同時拿同一個舊 refresh token 來換,只有第一個會找到它,第二個會得到 False。
清理過期會話時,判斷標準改成 refresh token 過期或閒置超時:
def cleanup_expired_sessions(self) -> int:
"""清理過期的會話,返回被清理的數量"""
count = 0
with self.lock:
for user_id in list(self.sessions.keys()):
before = len(self.sessions[user_id])
self.sessions[user_id] = [
s for s in self.sessions[user_id]
if not s.is_refresh_expired() and not s.is_inactive(self.inactivity_minutes)
]
count += before - len(self.sessions[user_id])
if not self.sessions[user_id]:
del self.sessions[user_id]
return count
access token 過期(15 分鐘)不代表會話結束,使用者還能用 refresh token 換新的,所以不能以它為清理標準。create_session、revoke_session、revoke_all_sessions、get_user_sessions 的完整代碼見 src/session_manager.py。
全域實例:
session_manager = SessionManager(
max_sessions_per_user=5,
inactivity_minutes=int(os.getenv("SESSION_INACTIVITY_MINUTES", "30")),
)
每個用戶最多 5 個會話,超過時移除最舊的一個。
修改 src/api_auth.py。登入時簽發兩個令牌並登記會話:
@router.post("/login", response_model=LoginResponse)
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)):
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=401, detail="郵箱或密碼錯誤",
headers={"WWW-Authenticate": "Bearer"})
access_token = create_access_token(
data={"sub": user.id}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
refresh_token = create_refresh_token({"sub": user.id})
session_manager.create_session(
user_id=user.id, access_token=access_token, refresh_token=refresh_token,
ip_address=request.client.host if request.client else "",
user_agent=request.headers.get("user-agent", ""),
)
return {"access_token": access_token, "refresh_token": refresh_token,
"token_type": "bearer", "user": UserResponse.model_validate(user)}
get_current_user 在驗完簽章後多一道檢查:
# 令牌簽章有效還不夠,會話也必須存在(登出、閒置超時、伺服器重啟後即失效)
if session_manager.get_session(user_id, token) is None:
raise HTTPException(status_code=401, detail="會話已失效,請重新登入",
headers={"WWW-Authenticate": "Bearer"})
因為 Day 16 的 /api/v1/detect、/jobs 等端點都依賴 get_current_user,這一行讓它們全部自動受到會話管理保護。
刷新端點:
@router.post("/refresh", response_model=TokenPair)
async def refresh(body: RefreshRequest):
payload = verify_refresh_token(body.refresh_token)
if payload is None:
raise HTTPException(status_code=401, detail="刷新令牌無效或已過期")
user_id = payload["user_id"]
if session_manager.get_session_by_refresh(user_id, body.refresh_token) is None:
raise HTTPException(status_code=401, detail="會話已失效,請重新登入")
new_access = create_access_token(
{"sub": user_id}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
new_refresh = create_refresh_token({"sub": user_id})
if not session_manager.rotate_session(user_id, body.refresh_token, new_access, new_refresh):
raise HTTPException(status_code=401, detail="會話已失效,請重新登入")
return {"access_token": new_access, "refresh_token": new_refresh, "token_type": "bearer"}
三道檢查依序是:refresh token 的簽章與效期(verify_refresh_token 會拒絕 type 不是 refresh 的令牌)、它屬於一個還存在的會話、輪換成功。輪換後,舊的 access token 也跟著失效,因為會話裡記的已經是新的那組。
登出改成真正撤銷:
@router.post("/logout")
async def logout(token: str = Depends(oauth2_scheme),
current_user: User = Depends(get_current_user)):
session_manager.revoke_session(current_user.id, token)
return {"message": "已登出"}
/logout-all(revoke_all_sessions)與 /sessions(get_user_sessions)的寫法相同,完整代碼見 src/api_auth.py。
修改 frontend/apiClient.js。令牌存在 localStorage,重新整理頁面後仍維持登入:
export const tokenStore = {
get access() { return localStorage.getItem('token') },
get refresh() { return localStorage.getItem('refresh_token') },
set(access, refresh) {
localStorage.setItem('token', access)
if (refresh) localStorage.setItem('refresh_token', refresh)
},
clear() {
localStorage.removeItem('token')
localStorage.removeItem('refresh_token')
},
}
刷新請求同一時間只送一個:
let refreshing = null
function refreshTokens() {
if (!refreshing) {
refreshing = request('/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: tokenStore.refresh }),
})
.then((data) => {
tokenStore.set(data.access_token, data.refresh_token)
return data.access_token
})
.finally(() => { refreshing = null })
}
return refreshing
}
所有需要登入的請求都經過 authRequest():
export async function authRequest(path, options = {}) {
const withToken = (token) => ({
...options,
headers: { ...(options.headers || {}), Authorization: `Bearer ${token}` },
})
const usedToken = tokenStore.access
try {
return await request(path, withToken(usedToken))
} catch (err) {
if (err.status !== 401 || !tokenStore.refresh) throw err
}
// 等到 401 回來時,別的請求可能已經刷新過了:直接用新令牌重試,不要再刷新一次
if (tokenStore.access && tokenStore.access !== usedToken) {
return request(path, withToken(tokenStore.access))
}
let newToken
try {
newToken = await refreshTokens()
} catch (err) {
tokenStore.clear()
onSessionExpired() // App.jsx 註冊的回呼:切回登入畫面
throw new Error('登入已過期,請重新登入')
}
return request(path, withToken(newToken))
}
中間那段「別的請求已經刷新過了」的判斷,是端對端測試抓到競爭條件後才補上的,下一節會看到實際的請求紀錄。
frontend/App.jsx 在登入時呼叫 tokenStore.set(data.access_token, data.refresh_token),在初始化時用 setOnSessionExpired() 註冊「回到登入畫面」;/auth/me、/auth/jobs、/auth/logout 都改用 authRequest()。
cd srs-review-agent
python3 -m pytest tests/test_day17_refresh_token.py -v
tests/test_day17_refresh_token.py::test_refresh_token_creation PASSED [ 10%]
tests/test_day17_refresh_token.py::test_refresh_access_token PASSED [ 20%]
tests/test_day17_refresh_token.py::test_session_manager_creation PASSED [ 30%]
tests/test_day17_refresh_token.py::test_concurrent_sessions PASSED [ 40%]
tests/test_day17_refresh_token.py::test_session_expiration PASSED [ 50%]
tests/test_day17_refresh_token.py::test_session_cleanup PASSED [ 60%]
tests/test_day17_refresh_token.py::test_session_stats PASSED [ 70%]
tests/test_day17_refresh_token.py::test_api_refresh_rotation PASSED [ 80%]
tests/test_day17_refresh_token.py::test_api_logout_revokes PASSED [ 90%]
tests/test_day17_refresh_token.py::test_api_inactivity_logout PASSED [100%]
============================== 10 passed in 0.35s ==============================
test_session_cleanup:access token 過期但仍在活動中的會話保留,閒置 1 小時的會話被清掉,回傳值為 1test_api_refresh_rotation:refresh token 打 /me 回 401;刷新後新 token 可用,舊 access token 與舊 refresh token 都回 401test_api_logout_revokes:裝置 A 登出後 A 的 token 失效、裝置 B 不受影響;/logout-all 撤銷剩下的 1 個會話test_api_inactivity_logout:把最後活動時間往前調 31 分鐘,訪問與刷新都被拒絕登入回應多了 refresh_token:
curl -X POST http://localhost:8000/api/v1/auth/login -d "username=alice@example.com&password=SecurePass123"
# {"access_token": "...", "refresh_token": "...", "token_type": "bearer", "user": {...}}
| 請求 | 結果 |
|---|---|
用 refresh token 呼叫 /api/v1/auth/me |
401「無效的認證令牌」 |
POST /api/v1/auth/refresh |
200,回傳新的 access_token、refresh_token |
| 再用同一個舊 refresh token 刷新 | 401「會話已失效,請重新登入」 |
用刷新前的舊 access token 呼叫 /me |
401「會話已失效,請重新登入」 |
刷新令牌亂填 garbage |
401「刷新令牌無效或已過期」 |
POST /api/v1/auth/logout 後呼叫 /me |
401「會話已失效,請重新登入」(Day 15 時是 200) |
另一台裝置的 token 呼叫 /me |
200,不受影響 |
POST /api/v1/auth/logout-all |
{"message": "已在所有裝置登出", "revoked": 1} |
GET /api/v1/auth/sessions 列出登入中的裝置:
{"sessions": [
{"created_at": "2026-09-29T06:30:38.982092", "last_activity": "2026-09-29T06:30:38.991226",
"ip_address": "testclient", "user_agent": "curl/8.7.1"},
{"created_at": "2026-09-29T06:30:38.990418", "last_activity": "2026-09-29T06:30:38.990418",
"ip_address": "testclient", "user_agent": "curl/8.7.1"}
]}
回應只包含時間、IP 與 User-Agent,不會把令牌本身回傳出來。
把後端的 access token 效期縮到 1 分鐘,用 Playwright 驅動 headless Chromium:
ACCESS_TOKEN_EXPIRE_MINUTES=1 uvicorn src.api_main:app --port 8000
註冊、登入、提交一次檢測後,等 65 秒讓 access token 過期,再點「檢測結果」:
401 GET /api/v1/auth/jobs ← access token 過期
200 POST /api/v1/auth/refresh ← authRequest 自動刷新
200 GET /api/v1/auth/jobs ← 用新 token 重試成功
畫面正常顯示任務列表,使用者完全不需要重新登入。登出後,用舊 token 直接呼叫 /me 得到 401。
再測一個情境:access token 過期後重新整理頁面。開發模式下 React 的 StrictMode 會把初始化的 useEffect 執行兩次,等於同時發出兩個 /me。第一版 authRequest() 的紀錄是:
401 GET /api/v1/auth/me
200 POST /api/v1/auth/refresh ← 請求 A 刷新
401 GET /api/v1/auth/me
200 POST /api/v1/auth/refresh ← 請求 B 又刷新一次,把 A 剛拿到的 token 輪換掉
401 GET /api/v1/auth/me ← A 用自己拿到的 token 重試,已經失效
200 GET /api/v1/auth/me
401 GET /api/v1/auth/jobs ← 任務列表載入失敗,畫面上是空的
「同時只送一個刷新請求」的機制沒有擋住,因為 B 的 401 回來時,A 的刷新已經結束了,refreshing 又是 null。修正方式是記下這次請求用的是哪個 token:401 回來時如果 tokenStore.access 已經換了,代表別人刷新過,直接用新 token 重試。修正後:
401 GET /api/v1/auth/me
200 POST /api/v1/auth/refresh ← 只刷新一次
401 GET /api/v1/auth/me
200 GET /api/v1/auth/me ← B 發現 token 已更新,直接重試
200 GET /api/v1/auth/me
200 GET /api/v1/auth/jobs
200 GET /api/v1/auth/jobs
這種問題單元測試很難發現,因為它需要「兩個請求交錯」加上「令牌剛好過期」同時發生。輪換讓 refresh token 更安全,代價就是前端必須小心處理並行請求。
HttpOnly cookie,JavaScript 讀不到,但需要另外處理 CSRFSESSION_INACTIVITY_MINUTES 調整git add src/auth.py src/session_manager.py src/api_auth.py \
frontend/apiClient.js frontend/App.jsx tests/test_day17_refresh_token.py
git commit -m "Day 17: 刷新令牌輪換、伺服器端會話、登出撤銷、前端自動刷新"
| 天 | 成果 | 關鍵數字 |
|---|---|---|
| Day 8-10 | 規則擴展、語義分析、混合方案 | F1 0.468 / 0.524 / 0.388,都低於 Day 7 的 0.789 |
| Day 11 | Mistral 7B 驗證與補充 | F1 0.714,74.5 秒 |
| Day 12 | 批量驗證與緩存 | F1 0.767,56.1 秒,重跑 < 0.1 秒 |
| Day 13-14 | FastAPI 與 React 前端 | 任務模式、每秒輪詢 |
| Day 15-17 | 帳號、隔離、會話 | 登出後 token 立即失效 |
系統已經能從瀏覽器登入、提交 SRS 需求、看到衝突結果,但檢測品質還沒超越 Day 7 的基線:Day 12 的 F1 0.767 仍比 0.789 低,而且 Day 11-13 看到的 Mistral 附和偏誤、漏掉的數值衝突都還沒解決。
第 3 週回到檢測品質本身。明天 Day 18 會把 Day 10-13 累積的失敗案例(被放行的誤報、漏掉的數值衝突、補充層的英文與可疑結果)整理成一份清單,逐一分析原因,作為改進提示詞的依據。